Skip to content

Allow underscores in conversation tag keys#3621

Draft
chuckbutkus wants to merge 1 commit into
mainfrom
allow-underscore-tag-keys
Draft

Allow underscores in conversation tag keys#3621
chuckbutkus wants to merge 1 commit into
mainfrom
allow-underscore-tag-keys

Conversation

@chuckbutkus

@chuckbutkus chuckbutkus commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Relax the conversation tag key validator from ^[a-z0-9]+$ to ^[a-z0-9_]+$ so frontends can attach UI-specific metadata to a conversation using natural snake_case keys (e.g. selected_workspace, active_profile) instead of mashed-together strings like selectedworkspace.

Motivation

agent-canvas currently keeps a small per-conversation localStorage shim (conversation-metadata-store.ts) for UI-only fields the agent runtime doesn't care about:

  • selected_repository / selected_branch / git_provider — the home-page repo picker selection
  • selected_workspace — the local folder the user attached at conversation creation (used by the Files tab to default to diff view)
  • active_profile — the LLM profile name the conversation was created with / last switched to (the chat-header switcher needs this because several profiles can share the same model — see agent-canvas#1082)

These already round-trip cleanly through the existing tags field on ConversationInfo / UpdateConversationRequest, but the validator rejected the natural key names. The only mechanical workaround is to mash the names together (selectedworkspace, activeprofile), which is what we did for acpserver historically — readable but ugly, and it fans out a tag-key constants table that has to be kept in sync between the agent-server and every client.

With underscores allowed, agent-canvas can drop the localStorage shim and round-trip all five fields through tags using their normal names, completing the agent-canvas effort to eliminate per-conversation client-only state (related agent-canvas plan).

Change

  • TAG_KEY_PATTERN: ^[a-z0-9]+$^[a-z0-9_]+$
  • Doc-strings on ConversationTags, ConversationState.tags, CreateConversationRequest.tags, UpdateConversationRequest.tags, ForkConversationRequest.tags, and the Conversation/RemoteConversation tags kwargs updated to say "(underscores allowed)"
  • Existing "underscore rejected" test flipped to assert acceptance, plus a guard test for underscore-only keys (still valid under the relaxed pattern)

Backward compatibility

Strictly additive: every key that was valid before is still valid. Existing tags written by older clients (including the acpserver tag agent-canvas already uses) continue to work unchanged. Hyphens, uppercase, and whitespace remain rejected.

Tests

  • tests/sdk/conversation/test_tags.py — 13 passed (the underscore-rejection test became an acceptance test; one new test pins down the underscore-only edge case)
  • tests/sdk/conversation/ — full suite 564/564 passing
  • tests/agent_server/ — 1151/1152 passing (the one failure is an unrelated webhook timeout test that passes in isolation; not touched by this PR)

This pull request was opened by an AI agent (OpenHands) on behalf of @rbren as part of a wider agent-canvas cleanup; see the companion agent-canvas PR for the consumer-side change that motivated it.

@chuckbutkus can click here to continue refining the PR


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:12ce504-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-12ce504-python \
  ghcr.io/openhands/agent-server:12ce504-python

All tags pushed for this build

ghcr.io/openhands/agent-server:12ce504-golang-amd64
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-golang-amd64
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-golang-amd64
ghcr.io/openhands/agent-server:12ce504-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:12ce504-golang-arm64
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-golang-arm64
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-golang-arm64
ghcr.io/openhands/agent-server:12ce504-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:12ce504-java-amd64
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-java-amd64
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-java-amd64
ghcr.io/openhands/agent-server:12ce504-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:12ce504-java-arm64
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-java-arm64
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-java-arm64
ghcr.io/openhands/agent-server:12ce504-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:12ce504-python-amd64
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-python-amd64
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-python-amd64
ghcr.io/openhands/agent-server:12ce504-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:12ce504-python-arm64
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-python-arm64
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-python-arm64
ghcr.io/openhands/agent-server:12ce504-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:12ce504-golang
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-golang
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-golang
ghcr.io/openhands/agent-server:12ce504-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:12ce504-java
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-java
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-java
ghcr.io/openhands/agent-server:12ce504-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:12ce504-python
ghcr.io/openhands/agent-server:12ce50473a5f443a6fa9621273b31564fafe02c9-python
ghcr.io/openhands/agent-server:allow-underscore-tag-keys-python
ghcr.io/openhands/agent-server:12ce504-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 12ce504-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 12ce504-python-amd64) are also available if needed

Relax the conversation tag key validator from `^[a-z0-9]+$` to
`^[a-z0-9_]+$` so frontends can attach UI-specific metadata to a
conversation using natural snake_case keys (e.g. `selected_workspace`,
`active_profile`) instead of mashed-together strings like
`selectedworkspace`.

This is backward compatible: every previously-valid key (lowercase
alphanumeric, no underscores) still passes. The change unblocks
agent-canvas's deprecation of its client-side
`conversation-metadata-store` localStorage shim — it can now round-trip
conversation metadata through the existing `tags` field on the server
instead of duplicating state in the browser.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/conversation
   request.py72888%62, 229, 232, 234–235, 238–239, 250
   state.py227796%260–261, 267–269, 449, 640
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py7255792%92, 413–414, 435, 440, 585, 631, 700, 716, 792, 1037–1038, 1115–1116, 1119, 1239, 1242–1243, 1267, 1300–1301, 1304, 1310, 1391, 1394, 1398–1399, 1403–1404, 1407, 1414, 1439, 1443, 1446, 1465, 1517, 1520, 1559, 1567, 1571–1573, 1580, 1692, 1697, 1807, 1809, 1813–1814, 1825–1826, 1851, 2046, 2050, 2120, 2127–2128
   remote_conversation.py7008587%144, 171, 184, 186–189, 208, 221–222, 227–230, 314, 324–326, 332, 410, 557–560, 562, 588–592, 597–600, 603, 619, 751, 799–800, 804–805, 819, 833–836, 877, 890–891, 915–916, 939–942, 944–945, 971, 981, 985, 994–995, 1034, 1184–1185, 1279–1280, 1284, 1289–1293, 1299–1305, 1318, 1323, 1365, 1578–1579
TOTAL30471675477% 

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

Snake_case conversation tag keys now work through the SDK and local agent-server request path, while existing alphanumeric keys remain accepted.

Does this PR achieve its stated goal?

Yes. The PR set out to relax conversation tag keys so frontends can round-trip UI metadata like selected_workspace, active_profile, and git_provider through tags. I verified that those keys are rejected on origin/main but accepted on the PR branch via the public Conversation(...) SDK constructor, StartConversationRequest/agent-server request models, and real HTTP PATCH /api/conversations/{id} requests to a local agent server. I also verified existing mashed alphanumeric keys still work and invalid hyphen/uppercase/space keys remain rejected by request validation.

Phase Result
Environment Setup make build completed successfully and installed the repo packages into .venv
CI Status ⚠️ 22 checks passing, 1 failing (PR Description Check/Validate PR description), 7 pending, 1 skipped at time of QA
Functional Verification ✅ SDK constructor, request-model validation, and local HTTP PATCH behavior verified before/after
Functional Verification

Test 1: Public SDK Conversation(...) constructor accepts snake_case tags

Step 1 — Reproduce baseline without the fix:
Ran git checkout origin/main followed by this SDK script:

OPENHANDS_SUPPRESS_BANNER=1 uv run python - <<'PY'
from pydantic import ValidationError
from openhands.sdk import Agent, Conversation, LLM
agent = Agent(llm=LLM(model="gpt-4o-mini", api_key="dummy"), tools=[])
try:
    conversation = Conversation(
        agent=agent,
        workspace="/tmp/workspace",
        tags={"selected_workspace": "/tmp/workspace", "active_profile": "default"},
    )
except ValidationError as exc:
    error = exc.errors()[0]
    print(f"Conversation constructor: REJECTED | loc={error['loc']} | msg={error['msg']}")
else:
    print(f"Conversation constructor: ACCEPTED | state.tags={conversation.state.tags}")
PY

Output:

Conversation constructor: REJECTED | loc=('tags',) | msg=Value error, Tag key 'selected_workspace' is invalid: keys must be lowercase alphanumeric only

This confirms the old SDK behavior blocked natural snake_case frontend metadata keys.

Step 2 — Apply the PR's changes:
Checked out allow-underscore-tag-keys at 12ce50473a5f443a6fa9621273b31564fafe02c9.

Step 3 — Re-run with the fix in place:
Ran the same SDK script on the PR branch.
Output:

Conversation constructor: ACCEPTED | state.tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default'}

This confirms the public SDK conversation creation path now preserves snake_case tags.

Test 2: Start/update/fork request models accept snake_case and preserve compatibility

Step 1 — Reproduce baseline without the fix:
On origin/main, I created a real LocalWorkspace and built StartConversationRequest objects with agent_settings={}:

StartConversationRequest snake_case: REJECTED | loc=('tags',) | msg=Value error, Tag key 'selected_workspace' is invalid: keys must be lowercase alphanumeric only
StartConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default'}

This shows the old workaround keys worked, while the desired snake_case keys did not.

Step 2 — Apply the PR's changes:
Checked out the PR branch again.

Step 3 — Re-run with the fix in place:
Ran request-model construction for create/update/fork-style payloads with realistic frontend metadata:

StartConversationRequest snake_case: ACCEPTED | tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default'}
StartConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default'}
UpdateConversationRequest snake_case: ACCEPTED | tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default', 'git_provider': 'github'}
UpdateConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default', 'gitprovider': 'github'}
UpdateConversationRequest underscore-only: ACCEPTED | tags={'___': 'value'}
ForkConversationRequest snake_case: ACCEPTED | tags={'selected_workspace': '/tmp/workspace', 'active_profile': 'default', 'git_provider': 'github'}
ForkConversationRequest mashed: ACCEPTED | tags={'selectedworkspace': '/tmp/workspace', 'activeprofile': 'default', 'gitprovider': 'github'}
UpdateConversationRequest invalid hyphen: REJECTED | loc=('tags',) | msg=Value error, Tag key 'selected-workspace' is invalid: keys must be lowercase alphanumeric (underscores allowed)
UpdateConversationRequest invalid uppercase: REJECTED | loc=('tags',) | msg=Value error, Tag key 'Active_Profile' is invalid: keys must be lowercase alphanumeric (underscores allowed)
UpdateConversationRequest invalid space: REJECTED | loc=('tags',) | msg=Value error, Tag key 'active profile' is invalid: keys must be lowercase alphanumeric (underscores allowed)

This confirms the intended additive behavior: underscores are accepted, previous alphanumeric keys still work, and the documented invalid forms are still rejected.

Test 3: Local agent-server HTTP PATCH no longer rejects snake_case tags

Step 1 — Reproduce baseline without the fix:
Started the base branch server with:

uv run python -m openhands.agent_server --host 127.0.0.1 --port 18081

Then sent real PATCH requests to a dummy conversation id:

CID=00000000-0000-0000-0000-000000000001
curl -sS -X PATCH "http://127.0.0.1:18081/api/conversations/$CID" \
  -H 'Content-Type: application/json' \
  -d '{"tags":{"selected_workspace":"/tmp/workspace","active_profile":"default","git_provider":"github"}}' \
  -w '\nHTTP %{http_code}\n'

curl -sS -X PATCH "http://127.0.0.1:18081/api/conversations/$CID" \
  -H 'Content-Type: application/json' \
  -d '{"tags":{"selectedworkspace":"/tmp/workspace","activeprofile":"default","gitprovider":"github"}}' \
  -w '\nHTTP %{http_code}\n'

Output:

BASE snake_case PATCH:
{"detail":"Internal Server Error","exception":"Object of type ValueError is not JSON serializable","error_id":"ecc35e50f78c475bbebdb9129e88dd78"}
HTTP 500

BASE mashed PATCH:
{"success":false}
HTTP 200

The dummy conversation does not exist, so {"success":false} is expected once request validation passes. The key before/after signal is that base snake_case failed during validation while mashed keys reached the route logic.

Step 2 — Apply the PR's changes:
Stopped the base server, checked out the PR branch, and started:

uv run python -m openhands.agent_server --host 127.0.0.1 --port 18080

Step 3 — Re-run with the fix in place:
Ran the same snake_case PATCH against the PR server:

PR snake_case PATCH:
{"success":false}
HTTP 200

This shows snake_case tags now pass the agent-server request validation and reach the update route, matching the PR's goal.

I also checked an invalid hyphen key on both branches; it remained rejected on both. The local server currently serializes that validation failure as HTTP 500 rather than a clean 422, but that behavior existed before this PR and the invalid key is not accepted.

Issues Found

None blocking this PR's stated goal. Existing invalid-tag API errors returned HTTP 500 on both base and PR during local server QA; I did not treat that as PR-introduced.

This review was created by an AI agent (OpenHands) on behalf of the user.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants